//
//  DPTestsModule.m
//  UITestingKit
//
//  Created by Ofri Wolfus on 03/02/07.
//  Copyright 2007 Ofri Wolfus. All rights reserved.
//

#import "DPTestsModule.h"
#import "NSBundleAdditions.h"
#import "DPTestCase-Private.h"
#import "DPTestStatistics.h"


@interface DPTestsModule (private)
- (id)initWithPath:(NSString *)path;
- (BOOL)prepareNextCase;
- (void)addTestStatistics:(DPTestStatistics *)stats;
- (BOOL)finished;
- (void)invalidate;
- (BOOL)verifyState:(DPTestStatistics *)stats;
@end

#define TESTS_MODULE_EXTENSION @".testsModule"

@implementation DPTestsModule

static NSMutableDictionary *_modules = nil;

+ (void)initialize {
	_modules = [[NSMutableDictionary alloc] init];
}

+ (NSArray *)modulesAtDirectory:(NSString *)dirPath {
	NSMutableArray *tests = [[NSMutableArray alloc] init];
	NSEnumerator *subpaths = [[[NSFileManager defaultManager] subpathsAtPath:dirPath] objectEnumerator];
	NSString *path;
	
	while ((path = [subpaths nextObject])) {
		if ([path hasSuffix:TESTS_MODULE_EXTENSION])
			[tests addObject:[self moduleAtPath:[dirPath stringByAppendingPathComponent:path]]];
	}
	
	return [tests autorelease];
}

+ (id)moduleAtPath:(NSString *)path {
	id module = nil;
	
	@synchronized (_modules) {
		module = [[[_modules valueForKey:path] retain] autorelease];
	}
	
	return module ?: [[[self alloc] initWithPath:path] autorelease];
}

- (id)initWithPath:(NSString *)path {
	@synchronized (_modules) {
		id module = [_modules valueForKey:path];
		
		// If we already have a module for that path we'll reuse it
		if (module) {
			// Can't -release here as -dealloc tries to remove the
			// object from the _modules dictionary, and since it's
			// not in it, an exception will be thrown.
			NSDeallocateObject(self);
			self = [module retain];
		} else {	// otherwise, try to create a new module
			if ((self = [super init])) {
				bundle = [[NSBundle bundleWithPath:path] retain];
				
				/*
				 * NSBundle modifies its info dictionary once loaded
				 * which will result in an incorrect principal class if
				 * no principal class is available. Specifically, a value
				 * for the NSPrincipalClass key will be added even if
				 * not actually available in the Info.plist file. Yuck!
				 * Therefor, we copy the info dict *before* loading the bundle.
				 */
				bundleInfo = [[bundle infoDictionary] copy];
				// OK folks, let's load it
				[bundle load];
				
				// Find all subclasses of DPTestCase and add them to our
				// cases array
				NSEnumerator *enumerator = [[bundle classes] objectEnumerator];
				Class cls;
				
				cases = [[NSMutableArray allocWithZone:[self zone]] init];
				while ((cls = [enumerator nextObject]))
					if (class_isSubclassOfClass(cls, [DPTestCase class]))
						[cases addObject:cls];
				
				// Sort our cases alphabetically
				NSSortDescriptor *descriptor = [[[NSSortDescriptor alloc] initWithKey:@"className"
																			ascending:YES] autorelease];
				[cases sortUsingDescriptors:[NSArray arrayWithObject:descriptor]];
				
				// Init the rest of our ivars
				leftCases = [cases mutableCopy];
				statistics = [[NSMutableDictionary allocWithZone:[self zone]] initWithCapacity:[cases count]];
				completedCases = [[NSMutableArray allocWithZone:[self zone]] initWithCapacity:[cases count]];
				failedCases = [[NSMutableArray allocWithZone:[self zone]] initWithCapacity:[cases count]];
				currentCase = nil;
				catchExceptions = NO;
				stopOnFailure = YES;
				isValid = YES;
				status = DPTestPending;
				
				/*
				 * For loadable bundles NSBundle always returns a class for
				 * -principalClass. If the principal class is not defined in
				 * the Info.plist file, the first class of the bundle will be
				 * returned which is *NOT* what we want.
				 */
				userClass = [bundleInfo objectForKey:@"NSPrincipalClass"] != nil ?
														 [bundle principalClass] : Nil;
				
				// Add ourself to the modules dictionary
				[_modules setObject:self
							 forKey:path];
			}
		}
	}
	
	return self;
}

- (void)dealloc {
	@synchronized (_modules) {
		[_modules removeObjectForKey:[bundle bundlePath]];
	}
	
	[[NSNotificationCenter defaultCenter] removeObserver:self];
	
	[statistics release]; statistics = nil;
	[leftCases release]; leftCases = nil;
	[failedCases release]; failedCases = nil;
	[completedCases release]; completedCases = nil;
	[cases release]; cases = nil;
	[bundle release]; bundle = nil;
	[super dealloc];
}

- (NSString *)description {
	return [NSString stringWithFormat:@"DPTestsModule <%p> (%@)", self, [self name]];
}

- (BOOL)stopsOnFailedTests {
	return stopOnFailure;
}

- (void)setStopsOnFailedTests:(BOOL)flag {
	stopOnFailure = flag;
}

- (BOOL)catchExceptions {
	return catchExceptions;
}

- (void)setCatchExceptions:(BOOL)flag {
	catchExceptions = flag;
}

- (NSString *)name {
	return [bundle bundleIdentifier];
}

/*
 * This method is being called by -nextTest whenever the nextTest method
 * of the current test case returns nil. When -[DPTestCase nextTest] returns
 * nil it means there are no tests left to be executed in the case (but doesn't
 * necessarily mean all tests have been verified). Therefor, we pop a new test
 * case class from our leftCases array, instantiate it and set currentCase to it.
 * -[DPTestsModule nextTest] will then execute all tests of the new case until
 * time to prepare a new case comes.
 */
- (BOOL)prepareNextCase {
	// Do nothing if we're not valid
	if (!isValid)
		NO;
	
	// We don't release the old case here.
	// It'll get released when it actually finishes its tests.
	currentCase = [[[leftCases lastObject] alloc] init];
	
	if (currentCase) {
		NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
		
		// Remove our current case
		[leftCases removeLastObject];
		
		// Listen to completion notification from our test case
		[nc addObserver:self
			   selector:@selector(caseCompleted:)
				   name:DPTestCaseCompletedNotification
				 object:currentCase];
		
		// Also listen to failure notificaions
		[nc addObserver:self
			   selector:@selector(testFailed:)
				   name:DPTestFailedNotification
				 object:currentCase];
	}
	
	return currentCase != nil;
}

- (DPTestStatistics *)nextTest {
	DPTestStatistics *s = nil;
	
	// Do nothing if we're invalid
	if (!isValid)
		return nil;
	
	// An exception is thrown whenever a test fails
	@try {
		s = [currentCase nextTest];
	} @catch (NSException *exception) {
		s = [[exception userInfo] valueForKey:DPTestStatisticsKey];
		
		if (![self catchExceptions])
			@throw exception;
	}
	
	// -[DPTestCase nextTest] returns nil when all tests have been executed
	// (but not necessarily verified).
	// In that case we load the next test case and start executing its tests.
	// When the previous case completes it posts a DPTestModuleCompletedNotification
	// notification which fires our -caseCompleted: method.
	if (!s) {
		// Load the next case
		if ([self prepareNextCase])
			// Recursively call ourself to trigger the next test.
			// This handles a situation of an empty test case, although this
			// situation shouldn't normally happen.
			s = [self nextTest];
	}
	
	if (s)
		[self addTestStatistics:s];
	
	[self verifyState:s];
	
	return s;
}

/*
 * Called whenever a DPTestCaseCompletedNotification notification is sent
 * by one of our test cases. This method then pushes the completed case
 * to the completedCases array, and posts a DPTestsModuleCompletedNotification
 * notification if needed.
 */
- (void)caseCompleted:(NSNotification *)notif {
	NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
	id testCase = [notif object];
	DPTestStatistics *stats = [[notif userInfo] valueForKey:DPTestStatisticsKey];
	
	// HACK: See the implementation of -[DPTestCase checkStatus:]
	// for detailed explanation.
	if (stats)
		[self addTestStatistics:stats];
	
	// This case is completed
	[completedCases addObject:[testCase class]];
	
	// No need to listen to notifications from this test case anymore
	[nc removeObserver:self
				  name:nil
				object:testCase];
	
	// Make sure we don't release our current case
	if (currentCase == testCase)
		[self prepareNextCase];
	
	// Clean up
	[testCase release];
	
	// Keep our status updated
	[self verifyState:nil];
}

/*
 * This method is being called by the default notification center whenever
 * a DPTestFailedNotification notification is sent by one of our test cases.
 * This method is responsible to take the necessary actions for a failed test.
 */
- (void)testFailed:(NSNotification *)notif {
	id testCase = [notif object];
	id stats = [[notif userInfo] valueForKey:DPTestStatisticsKey];
	
	// Remember the failed test
	[failedCases addObject:testCase];
	
	// Save the statistics object
	[self addTestStatistics:stats];
	
	if ([self stopsOnFailedTests])
		// Since we're done working, we can safely release it
		[testCase release];
	
	// Kepp our status up to date
	[self verifyState:stats];
}

/*
 * This method adds a statistics object to our statstics dictionary.
 * The statistics dictionary is made up of dictionaries, each containing the statistics
 * of all tests in a given test. For example, assumming we have a case named "MyCase1"
 * with "test1", "test2" and "test3", our statistics dict will contain a dictionary
 * associated with the @"MyCase1" key. This dict will have the @"test1", @"test2"
 * and @"test3" keys, each with a matching statistics object.
 */
- (void)addTestStatistics:(DPTestStatistics *)stats {
	NSMutableDictionary *caseStats = [statistics valueForKey:[stats caseName]];
	
	if (!caseStats) {
		caseStats = [[NSMutableDictionary allocWithZone:[statistics zone]] init];
		[statistics setValue:caseStats
					  forKey:[stats caseName]];
		[caseStats release];
	}
	
	[caseStats setValue:stats
				 forKey:[stats testName]];
}

- (BOOL)finished {
	/*
	 * We consider ourself valid if we have uncompleted cases.
	 * Uncompleted cases can be either cases waiting for verifications
	 * or failed cases.
	 * Also, if we have failed tests AND we shouldn't ignore them, we're invalid
	 * no matter what.
	 */
	return [completedCases count] + [failedCases count] < [cases count] &&
				([self stopsOnFailedTests] ? [failedCases count] > 0U : YES);
}

/*
 * Make sure our status is correct.
 * Returns YES if we invalidated ourself, NO otherwise.
 * This method is responsible of invalidating ourself when needed
 * and post the right notifications.
 * XXX This needs a better name!
 */
- (BOOL)verifyState:(DPTestStatistics *)stats {
	if ([self isValid]) {
		BOOL stops = [self stopsOnFailedTests];
		unsigned completed = [completedCases count];
		unsigned failed = [failedCases count];
		unsigned total = [cases count];
		NSString *notif = nil;
		NSDictionary *info = nil;
		
		// Inform everyone of completion
		if (completed == total ||
			(!stops && completed + failed == total)) {
			notif = DPTestsModuleCompletedNotification;
			status = DPTestSucceeded;
			
		// Inform everyone of our failure
		} else if (stops && failed > 0U) {
			notif = DPTestsModuleFailedNotification;
			status = DPTestFailed;
			
			if (stats)
				info = [NSDictionary dictionaryWithObject:stats
												   forKey:DPTestStatisticsKey];
		}
		
		if (notif) {
			// Post the notification
			[[NSNotificationCenter defaultCenter] postNotificationName:notif
																object:self
															  userInfo:info];
			// Invalidate
			[self invalidate];
			
			return YES;
		}
	}
	
	return NO;
}
		

- (BOOL)isValid {
	return isValid;
}

- (void)invalidate {
	isValid = NO;
}

- (NSDictionary *)statistics {
	return [[statistics copy] autorelease];
}

- (DPTestStatus)status {
	return status;
}

- (Class)userClass {
	return userClass;
}

- (unsigned)casesCount {
	return [cases count];
}

@end

NSString * const DPTestsModuleKey = @"DPTestsModuleName";
NSString * const DPTestCaseNameKey = @"DPTestCaseName";
NSString * const DPTestNameKey = @"DPTestName";
NSString * const DPTestResultKey = @"DPTestResult";
NSString * const DPReturnValueKey = @"DPReturnValue";
NSString * const DPExecutionTimeKey = @"DPExecutionTime";
NSString * const DPFailReasonKey = @"DPFailReason";
NSString * const DPTestStatisticsKey = @"DPTestStatistics";

NSString * const DPTestsModuleCompletedNotification = @"DPTestsModuleCompletedNotification";
NSString * const DPTestsModuleFailedNotification = @"DPTestsModuleFailedNotification";
